Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 68e6d65c5371c3bd2c85be20f9286a0b4fffdc3d


Parents : 86acc89
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-24T07:57:39-05:00

feat: bug fixes

Changes
Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8334c7ed..75e1cf99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -83,6 +83,14 @@ All notable changes to this project will be documented in this file.
- Map remote overlay loads undo layers from a stale generation, and TileCache map view keys are identity-scoped
- Nomad micron LXMF links route to Messages via onLxmfAddress instead of being ignored
- RNSH session config_path and identity_path are jailed under storage or the shared Reticulum config dir, and free-form extra_args are rejected
+- Failed identity switch no longer emits identity-switched (which wiped keep-alive UI) and clears the switching overlay via identity-switching-abort
+- Settings, About, and Interfaces refresh identity-scoped state on identity-switched
+- About snapshot/backup restore guards concurrent restores and reloads the web UI after a successful restore
+- Interfaces enable/disable write interface_enabled when neither legacy key exists, and disable returns the correct success message
+- Interfaces enable/disable roll back in-memory config when the Reticulum config write fails
+- Disabling block-all-from-strangers restores a prior inbound stamp cost of 0 instead of forcing 8
+- Discovery settings PATCH returns 500 when RNS reload fails after a successful disk write
+- Interfaces stats map is replaced each poll so deleted interfaces cannot stay Connected
- Desktop AppImage: main-process logs always append to the storage logs folder (meshchatx.log). Stdout is only used when a terminal is attached, with broken-pipe guards as a fallback, so background launches no longer raise write EPIPE dialogs
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android
- Android RNode BLE/USB via Chaquopy

diff --git a/meshchatx.rsm b/meshchatx.rsm
index bc897128..c8876941 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 9b06dbc0..5d79775b 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -6057,8 +6057,13 @@ class ReticulumMeshChat:
if new_value and not old_value:
# Enabling block strangers: save current stamp cost and set to max
current_cost = self.config.lxmf_inbound_stamp_cost.get()
- if current_cost < 254:
- self.config.lxmf_inbound_stamp_cost_before_block.set(current_cost)
+ if not isinstance(current_cost, int):
+ current_cost = 0
+ if current_cost < 0:
+ current_cost = 0
+ elif current_cost > 254:
+ current_cost = 254
+ self.config.lxmf_inbound_stamp_cost_before_block.set(current_cost)
self.config.lxmf_inbound_stamp_cost.set(254)
if self.message_router and self.local_lxmf_destination:
self.message_router.set_inbound_stamp_cost(
@@ -6073,14 +6078,16 @@ class ReticulumMeshChat:
destination_hash=self.local_lxmf_destination.hash,
)
elif not new_value and old_value:
- # Disabling block strangers: restore previous stamp cost
+ # Disabling block strangers: restore previous stamp cost.
+ # Zero is a valid prior cost (stamps off). Only fall back to 8
+ # when no prior cost was saved (sentinel outside 0..254).
saved = self.config.lxmf_inbound_stamp_cost_before_block.get()
- if saved > 0 and saved < 255:
+ if isinstance(saved, int) and 0 <= saved <= 254:
restore_cost = saved
else:
restore_cost = 8
self.config.lxmf_inbound_stamp_cost.set(restore_cost)
- self.config.lxmf_inbound_stamp_cost_before_block.set(0)
+ self.config.lxmf_inbound_stamp_cost_before_block.set(-1)
if self.message_router and self.local_lxmf_destination:
self.message_router.set_inbound_stamp_cost(
self.local_lxmf_destination.hash,

diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index 96012ec3..ce1f358c 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -124,8 +124,8 @@ class ConfigManager:
self.lxmf_inbound_stamp_cost_before_block = self.IntConfig(
self,
"lxmf_inbound_stamp_cost_before_block",
- 0,
- ) # saved stamp cost before block strangers was enabled
+ -1,
+ ) # prior stamp cost while block-all is on (-1 means unset)
self.lxmf_flood_protection_enabled = self.BoolConfig(
self,
"lxmf_flood_protection_enabled",

diff --git a/meshchatx/src/backend/http/routes/interfaces.py b/meshchatx/src/backend/http/routes/interfaces.py
index 8b9a6f42..2c654fe2 100644
--- a/meshchatx/src/backend/http/routes/interfaces.py
+++ b/meshchatx/src/backend/http/routes/interfaces.py
@@ -131,6 +131,8 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
zipfile,
)
+from meshchatx.src.backend.interface_enabled_flag import apply_interface_enabled_flag
+
def register_interfaces_routes(routes, app):
@@ -485,6 +487,7 @@ def register_interfaces_routes(routes, app):
)
# enable interface
+ interfaces_before_write = app._get_interfaces_snapshot()
interfaces = app._get_interfaces_section()
if interface_name not in interfaces:
return web.json_response(
@@ -502,10 +505,7 @@ def register_interfaces_routes(routes, app):
if i2p_error is not None:
return web.json_response({"message": i2p_error}, status=422)
- if "enabled" in interface:
- interface["enabled"] = "true"
- if "interface_enabled" in interface:
- interface["interface_enabled"] = "true"
+ apply_interface_enabled_flag(interface, enabled=True)
keys_to_remove = []
for key, value in interface.items():
@@ -515,7 +515,9 @@ def register_interfaces_routes(routes, app):
del interface[key]
# save config
- if not app._write_reticulum_config():
+ if not app._write_reticulum_config(
+ rollback_interfaces=interfaces_before_write,
+ ):
return web.json_response(
{
"message": "Failed to write Reticulum config",
@@ -547,6 +549,7 @@ def register_interfaces_routes(routes, app):
)
# disable interface
+ interfaces_before_write = app._get_interfaces_snapshot()
interfaces = app._get_interfaces_section()
if interface_name not in interfaces:
return web.json_response(
@@ -556,10 +559,7 @@ def register_interfaces_routes(routes, app):
status=404,
)
interface = interfaces[interface_name]
- if "enabled" in interface:
- interface["enabled"] = "false"
- if "interface_enabled" in interface:
- interface["interface_enabled"] = "false"
+ apply_interface_enabled_flag(interface, enabled=False)
keys_to_remove = []
for key, value in interface.items():
@@ -569,7 +569,9 @@ def register_interfaces_routes(routes, app):
del interface[key]
# save config
- if not app._write_reticulum_config():
+ if not app._write_reticulum_config(
+ rollback_interfaces=interfaces_before_write,
+ ):
return web.json_response(
{
"message": "Failed to write Reticulum config",
@@ -579,7 +581,7 @@ def register_interfaces_routes(routes, app):
return web.json_response(
{
- "message": "Interface deleted",
+ "message": "Interface is now disabled",
},
)

diff --git a/meshchatx/src/backend/http/routes/reticulum_instance.py b/meshchatx/src/backend/http/routes/reticulum_instance.py
index ddd49613..881113b6 100644
--- a/meshchatx/src/backend/http/routes/reticulum_instance.py
+++ b/meshchatx/src/backend/http/routes/reticulum_instance.py
@@ -242,9 +242,24 @@ def register_reticulum_instance_routes(routes, app):
)
try:
- await app.reload_reticulum()
+ reloaded = await app.reload_reticulum()
+ if reloaded is False:
+ return web.json_response(
+ {
+ "message": "Discovery settings saved but RNS reload failed",
+ "reloaded": False,
+ },
+ status=500,
+ )
except Exception as e:
logger.debug(f"Failed to reload RNS after discovery config update: {e}")
+ return web.json_response(
+ {
+ "message": f"Discovery settings saved but RNS reload failed: {e}",
+ "reloaded": False,
+ },
+ status=500,
+ )
discovery_config = {
"discover_interfaces": reticulum_config.get("discover_interfaces"),
@@ -270,6 +285,7 @@ def register_reticulum_instance_routes(routes, app):
else False,
),
"network_identity": reticulum_config.get("network_identity"),
+ "reloaded": True,
}
return web.json_response({"discovery": discovery_config})

diff --git a/meshchatx/src/backend/interface_enabled_flag.py b/meshchatx/src/backend/interface_enabled_flag.py
new file mode 100644
index 00000000..3cdb32c9
--- /dev/null
+++ b/meshchatx/src/backend/interface_enabled_flag.py
@@ -0,0 +1,22 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Helpers for Reticulum interface config flag mutations."""
+
+from __future__ import annotations
+
+
+def apply_interface_enabled_flag(interface: dict, *, enabled: bool) -> None:
+ """Set enabled/interface_enabled consistently, including missing-key configs.
+
+ Older or hand-edited configs may omit both keys. Enable/disable must still
+ write a flag so the next RNS reload honors the change.
+ """
+ if not isinstance(interface, dict):
+ raise TypeError("interface must be a dict")
+ value = "true" if enabled else "false"
+ if "enabled" in interface:
+ interface["enabled"] = value
+ if "interface_enabled" in interface:
+ interface["interface_enabled"] = value
+ if "enabled" not in interface and "interface_enabled" not in interface:
+ interface["interface_enabled"] = value

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index e8e05eda..618db363 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1072,6 +1072,7 @@ export default {
this.startClientHeapMemoryWatch();
GlobalEmitter.on("toast-dismissed", this.onToastDismissedShell);
GlobalEmitter.on("identity-switching-start", this.onIdentitySwitchingStartShell);
+ GlobalEmitter.on("identity-switching-abort", this.onIdentitySwitchingAbortShell);
GlobalEmitter.on("identity-switched-apply", this.onIdentitySwitchedApplyShell);
GlobalEmitter.on("sync-propagation-node", this.onSyncPropagationNodeShell);
GlobalEmitter.on("config-updated", this.onConfigUpdatedExternally);
@@ -1179,6 +1180,7 @@ export default {
WebSocketConnection.off("connected", this.onWsShellConnected);
this.unregisterShellWsHandlers();
GlobalEmitter.off("identity-switching-start", this.onIdentitySwitchingStartShell);
+ GlobalEmitter.off("identity-switching-abort", this.onIdentitySwitchingAbortShell);
GlobalEmitter.off("identity-switched-apply", this.onIdentitySwitchedApplyShell);
GlobalEmitter.off("sync-propagation-node", this.onSyncPropagationNodeShell);
GlobalEmitter.off("config-updated", this.onConfigUpdatedExternally);
@@ -1373,6 +1375,9 @@ export default {
}
}, 45000);
},
+ onIdentitySwitchingAbortShell() {
+ this.isSwitchingIdentity = false;
+ },
onIdentitySwitchedApplyShell(payload) {
this.applyIdentitySwitched(payload).catch(() => {});
},

diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 5f3e2c97..389ec6fe 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -1420,6 +1420,7 @@ export default {
this.restartAboutPollIntervals();
};
GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
this.sessionsWsHandler = (payload) => {
this.applyActiveSessionsPayload(payload);
};
@@ -1436,12 +1437,22 @@ export default {
if (this._batterySaverPrefsHandler) {
GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
}
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
if (this.sessionsWsHandler) {
offWsEvent("app.sessions.updated", this.sessionsWsHandler);
this.sessionsWsHandler = null;
}
},
methods: {
+ onIdentitySwitched() {
+ this.getAppInfo();
+ this.getActiveSessions();
+ this.getDatabaseHealth();
+ this.snapshotsOffset = 0;
+ this.autoBackupsOffset = 0;
+ this.listSnapshots();
+ this.listAutoBackups();
+ },
restartAboutPollIntervals() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
@@ -1578,20 +1589,33 @@ export default {
}
},
async restoreFromSnapshot(path) {
- if (!(await DialogUtils.confirm(this.$t("about.restore_snapshot_confirm")))) {
+ if (this.restoreInProgress) {
return;
}
+ this.restoreInProgress = true;
try {
+ if (!(await DialogUtils.confirm(this.$t("about.restore_snapshot_confirm")))) {
+ return;
+ }
const response = await window.api.post("/api/v1/database/restore", { path });
if (response.data.status === "success") {
ToastUtils.success(this.$t("about.database_restored"));
- if (this.isElectron) {
- setTimeout(() => ElectronUtils.relaunch(), 2000);
- }
+ this.scheduleRestoreRelaunch();
}
} catch {
ToastUtils.error(this.$t("about.failed_restore_snapshot"));
+ } finally {
+ this.restoreInProgress = false;
+ }
+ },
+ scheduleRestoreRelaunch() {
+ if (this.isElectron) {
+ setTimeout(() => ElectronUtils.relaunch(), 2000);
+ return;
}
+ setTimeout(() => {
+ window.location.reload();
+ }, 2000);
},
async getAppInfo() {
try {
@@ -1745,9 +1769,7 @@ export default {
this.databaseHealth = response.data.database?.health || this.databaseHealth;
this.databaseRecoveryActions = response.data.database?.actions || this.databaseRecoveryActions;
ToastUtils.success(this.$t("about.database_restored"));
- if (this.isElectron) {
- setTimeout(() => ElectronUtils.relaunch(), 2000);
- }
+ this.scheduleRestoreRelaunch();
await this.getDatabaseHealth();
} catch (e) {
this.restoreError = this.$t("about.failed_restore_file");

diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index 2c3d5f7d..6ac9b707 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -939,6 +939,7 @@ export default {
if (this._batterySaverPrefsHandler) {
GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
}
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
},
mounted() {
try {
@@ -962,9 +963,16 @@ export default {
this.startInterfacePollIntervals();
};
GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
this.startInterfacePollIntervals();
},
methods: {
+ onIdentitySwitched() {
+ this.loadInterfaces();
+ this.updateInterfaceStats();
+ this.loadDiscoveryConfig();
+ this.loadDiscoveredInterfaces();
+ },
startInterfacePollIntervals() {
clearInterval(this.reloadInterval);
clearInterval(this.discoveryInterval);
@@ -1011,14 +1019,17 @@ export default {
// fetch interface stats
const response = await window.api.get(`/api/v1/interface-stats`);
- // update data
+ // Replace the map so deleted/renamed interfaces do not keep
+ // stale Connected indicators for discovery peers.
+ const nextStats = {};
const interfaces = response.data.interface_stats?.interfaces ?? [];
for (const iface of interfaces) {
const key = iface.interface_name ?? iface.short_name;
if (key) {
- this.interfaceStats[key] = iface;
+ nextStats[key] = iface;
}
}
+ this.interfaceStats = nextStats;
} catch {
// do nothing if failed to load interfaces
}

diff --git a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
index eb0b41db..b9bcf24f 100644
--- a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
+++ b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
@@ -697,7 +697,9 @@ export default {
e.response?.data?.message || this.$t("identities.failed_switch") || "Failed to switch identity";
ToastUtils.error(errorMsg);
this.isCreating = false;
- GlobalEmitter.emit("identity-switched");
+ // Do not emit identity-switched on failure. Keep-alive pages treat
+ // that event as a real switch and clear identity-scoped UI.
+ GlobalEmitter.emit("identity-switching-abort");
}
},
async deleteIdentity(identity) {

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 0ee4ff2b..c7d06110 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -3280,10 +3280,12 @@ export default {
beforeUnmount() {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
this.getConfig();
this.getServerSecurity();
@@ -3300,6 +3302,16 @@ export default {
this.loadAndroidShellPrivacy();
},
methods: {
+ onIdentitySwitched() {
+ this.getConfig();
+ this.getServerSecurity();
+ this.getTrustedTelemetryPeers();
+ this.loadStickerCount();
+ this.loadGifCount();
+ this.loadBatteryInterfaceRows();
+ this.loadReticulumInstanceSettings();
+ this.loadAndroidShellPrivacy();
+ },
loadBatterySaverPrefsFromStorage() {
this.batterySaver = loadBatterySaverPrefs();
if (!this.batterySaver.interfaceBitrateLimits) {

diff --git a/tests/backend/test_interface_enabled_flag_oracle.py b/tests/backend/test_interface_enabled_flag_oracle.py
new file mode 100644
index 00000000..47d20928
--- /dev/null
+++ b/tests/backend/test_interface_enabled_flag_oracle.py
@@ -0,0 +1,41 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Oracle tests for interface enable/disable flag mutation."""
+
+from __future__ import annotations
+
+from meshchatx.src.backend.interface_enabled_flag import apply_interface_enabled_flag
+
+
+def test_oracle_enable_writes_flag_when_neither_key_present():
+ iface = {"type": "TCPClientInterface", "target_host": "127.0.0.1"}
+ apply_interface_enabled_flag(iface, enabled=True)
+ assert iface.get("interface_enabled") == "true"
+ assert "enabled" not in iface or iface["enabled"] == "true"
+
+
+def test_oracle_disable_writes_flag_when_neither_key_present():
+ iface = {"type": "TCPClientInterface"}
+ apply_interface_enabled_flag(iface, enabled=False)
+ assert iface["interface_enabled"] == "false"
+
+
+def test_oracle_enable_updates_both_legacy_keys():
+ iface = {"enabled": "false", "interface_enabled": "false"}
+ apply_interface_enabled_flag(iface, enabled=True)
+ assert iface["enabled"] == "true"
+ assert iface["interface_enabled"] == "true"
+
+
+def test_oracle_disable_route_message_is_not_deleted():
+ from pathlib import Path
+
+ src = Path("meshchatx/src/backend/http/routes/interfaces.py").read_text(
+ encoding="utf-8",
+ )
+ # The disable handler historically returned "Interface deleted".
+ assert 'message": "Interface is now disabled"' in src
+ disable_idx = src.index("async def reticulum_interfaces_disable")
+ delete_idx = src.index("async def reticulum_interfaces_delete")
+ disable_body = src[disable_idx:delete_idx]
+ assert "Interface deleted" not in disable_body

diff --git a/tests/backend/test_settings_interfaces_followup_oracle.py b/tests/backend/test_settings_interfaces_followup_oracle.py
new file mode 100644
index 00000000..ebc2e708
--- /dev/null
+++ b/tests/backend/test_settings_interfaces_followup_oracle.py
@@ -0,0 +1,88 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Oracles for settings stamp restore and interface enable rollback."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from meshchatx.meshchat import ReticulumMeshChat
+
+
+@pytest.mark.asyncio
+async def test_oracle_block_all_restore_preserves_zero_stamp_cost():
+ """Disabling block-all must restore stamps-off (0), not fall back to 8."""
+ app = MagicMock(spec=ReticulumMeshChat)
+ app.config = MagicMock()
+ app.message_router = None
+ app.local_lxmf_destination = None
+ app._parse_bool = staticmethod(ReticulumMeshChat._parse_bool)
+ app.sync_telephone_call_policy = MagicMock()
+ app.send_config_to_websocket_clients = AsyncMock()
+ app.send_active_sessions_to_websocket_clients = AsyncMock()
+
+ stamp_cost = {"v": 0}
+ before = {"v": -1}
+ block = {"v": False}
+
+ app.config.lxmf_inbound_stamp_cost.get.side_effect = lambda: stamp_cost["v"]
+ app.config.lxmf_inbound_stamp_cost.set.side_effect = lambda v: stamp_cost.__setitem__(
+ "v",
+ v,
+ )
+ app.config.lxmf_inbound_stamp_cost_before_block.get.side_effect = (
+ lambda: before["v"]
+ )
+ app.config.lxmf_inbound_stamp_cost_before_block.set.side_effect = (
+ lambda v: before.__setitem__("v", v)
+ )
+ app.config.block_all_from_strangers.get.side_effect = lambda: block["v"]
+ app.config.block_all_from_strangers.set.side_effect = lambda v: block.__setitem__(
+ "v",
+ v,
+ )
+
+ await ReticulumMeshChat.update_config(app, {"block_all_from_strangers": True})
+ assert stamp_cost["v"] == 254
+ assert before["v"] == 0
+
+ await ReticulumMeshChat.update_config(app, {"block_all_from_strangers": False})
+ assert stamp_cost["v"] == 0
+ assert before["v"] == -1
+
+
+def test_oracle_enable_disable_pass_rollback_interfaces():
+ src = Path("meshchatx/src/backend/http/routes/interfaces.py").read_text(
+ encoding="utf-8",
+ )
+ enable_idx = src.index("async def reticulum_interfaces_enable")
+ disable_idx = src.index("async def reticulum_interfaces_disable")
+ delete_idx = src.index("async def reticulum_interfaces_delete")
+ enable_body = src[enable_idx:disable_idx]
+ disable_body = src[disable_idx:delete_idx]
+ assert "rollback_interfaces=interfaces_before_write" in enable_body
+ assert "rollback_interfaces=interfaces_before_write" in disable_body
+ assert "_get_interfaces_snapshot()" in enable_body
+ assert "_get_interfaces_snapshot()" in disable_body
+
+
+def test_oracle_discovery_patch_reports_reload_failure():
+ src = Path(
+ "meshchatx/src/backend/http/routes/reticulum_instance.py",
+ ).read_text(encoding="utf-8")
+ patch_idx = src.index("async def reticulum_discovery_patch")
+ next_idx = src.index("async def reticulum_discovered_interfaces")
+ body = src[patch_idx:next_idx]
+ assert "RNS reload failed" in body
+ assert "status=500" in body
+
+
+def test_oracle_interface_stats_replace_map():
+ src = Path(
+ "meshchatx/src/frontend/components/interfaces/InterfacesPage.vue",
+ ).read_text(encoding="utf-8")
+ assert "this.interfaceStats = nextStats" in src
+ assert "const nextStats = {}" in src

diff --git a/tests/frontend/AppIdentitySwitch.test.js b/tests/frontend/AppIdentitySwitch.test.js
index 01ad42ab..faa6d5a1 100644
--- a/tests/frontend/AppIdentitySwitch.test.js
+++ b/tests/frontend/AppIdentitySwitch.test.js
@@ -169,4 +169,10 @@ describe("App.vue applyIdentitySwitched", () => {
await Promise.resolve();
expect(inner).toHaveBeenCalledWith({ identity_hash: "x", display_name: "Y" });
});
+
+ it("onIdentitySwitchingAbortShell clears the switching overlay", () => {
+ const ctx = { isSwitchingIdentity: true };
+ App.methods.onIdentitySwitchingAbortShell.call(ctx);
+ expect(ctx.isSwitchingIdentity).toBe(false);
+ });
});

diff --git a/tests/frontend/settingsAboutInterfacesIdentitiesOracle.test.js b/tests/frontend/settingsAboutInterfacesIdentitiesOracle.test.js
new file mode 100644
index 00000000..1e12fe71
--- /dev/null
+++ b/tests/frontend/settingsAboutInterfacesIdentitiesOracle.test.js
@@ -0,0 +1,181 @@
+// SPDX-License-Identifier: 0BSD
+
+import { readFileSync } from "fs";
+import { join } from "path";
+import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
+import { mount } from "@vue/test-utils";
+import IdentitiesPage from "../../meshchatx/src/frontend/components/settings/IdentitiesPage.vue";
+import AboutPage from "../../meshchatx/src/frontend/components/about/AboutPage.vue";
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+import DialogUtils from "../../meshchatx/src/frontend/js/DialogUtils";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+import ElectronUtils from "../../meshchatx/src/frontend/js/ElectronUtils";
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn().mockResolvedValue(true),
+ alert: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/DownloadUtils", () => ({
+ default: {
+ downloadFromApiResponse: vi.fn(),
+ downloadFile: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({
+ default: {
+ isElectron: () => false,
+ relaunch: vi.fn(),
+ getMemoryUsage: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/registries/wsEventRegistry.js", () => ({
+ onWsEvent: vi.fn(),
+ offWsEvent: vi.fn(),
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/deviceBattery.js", () => ({
+ appBatteryUsageToneClass: () => "",
+ batteryStatusIconName: () => "battery",
+ formatAppBatteryShareLabel: () => "",
+ formatAppBatteryUsageLabel: () => "",
+ formatProcessUptime: () => "",
+ getDeviceBatteryStatus: vi.fn().mockResolvedValue(null),
+ isNativeBatteryStatus: () => false,
+}));
+
+describe("settings/about/interfaces/identities exploratory oracles", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ delete window.api;
+ });
+
+ it("failed identity switch does not emit identity-switched (avoids wiping keep-alive UI)", async () => {
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ window.api = {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ identities: [
+ { hash: "hash1", display_name: "A", is_current: true },
+ { hash: "hash2", display_name: "B", is_current: false },
+ ],
+ },
+ }),
+ post: vi.fn().mockRejectedValue({ response: { data: { message: "busy" } } }),
+ };
+
+ const wrapper = mount(IdentitiesPage, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: true,
+ LxmfUserIcon: true,
+ },
+ mocks: {
+ $t: (k) => k,
+ },
+ },
+ });
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getIdentities();
+ emitSpy.mockClear();
+
+ await wrapper.vm.switchIdentity({
+ hash: "hash2",
+ display_name: "B",
+ is_current: false,
+ });
+
+ const emitted = emitSpy.mock.calls.map((c) => c[0]);
+ expect(emitted).toContain("identity-switching-start");
+ expect(emitted).toContain("identity-switching-abort");
+ expect(emitted).not.toContain("identity-switched");
+ expect(emitted).not.toContain("identity-switched-apply");
+ expect(ToastUtils.error).toHaveBeenCalled();
+ wrapper.unmount();
+ emitSpy.mockRestore();
+ });
+
+ it("SettingsPage listens for identity-switched and refreshes config", () => {
+ const src = readFileSync(
+ join(process.cwd(), "meshchatx/src/frontend/components/settings/SettingsPage.vue"),
+ "utf8"
+ );
+ expect(src).toContain('GlobalEmitter.on("identity-switched"');
+ expect(src).toContain("onIdentitySwitched()");
+ expect(src).toMatch(/onIdentitySwitched\(\)\s*\{[\s\S]*getConfig\(\)/);
+ });
+
+ it("AboutPage listens for identity-switched and refreshes backups/snapshots", () => {
+ const src = readFileSync(
+ join(process.cwd(), "meshchatx/src/frontend/components/about/AboutPage.vue"),
+ "utf8"
+ );
+ expect(src).toContain('GlobalEmitter.on("identity-switched"');
+ expect(src).toMatch(/onIdentitySwitched\(\)\s*\{[\s\S]*listSnapshots\(\)/);
+ expect(src).toMatch(/onIdentitySwitched\(\)\s*\{[\s\S]*listAutoBackups\(\)/);
+ });
+
+ it("InterfacesPage listens for identity-switched and reloads interface lists", () => {
+ const src = readFileSync(
+ join(process.cwd(), "meshchatx/src/frontend/components/interfaces/InterfacesPage.vue"),
+ "utf8"
+ );
+ expect(src).toContain('GlobalEmitter.on("identity-switched"');
+ expect(src).toMatch(/onIdentitySwitched\(\)\s*\{[\s\S]*loadInterfaces\(\)/);
+ });
+
+ it("About restoreFromSnapshot guards restoreInProgress and reloads web UI", async () => {
+ vi.useFakeTimers();
+ const reloadSpy = vi.fn();
+ const originalLocation = window.location;
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { reload: reloadSpy },
+ });
+
+ const ctx = {
+ restoreInProgress: false,
+ isElectron: false,
+ $t: (k) => k,
+ scheduleRestoreRelaunch: AboutPage.methods.scheduleRestoreRelaunch,
+ };
+ ctx.scheduleRestoreRelaunch = AboutPage.methods.scheduleRestoreRelaunch.bind(ctx);
+
+ window.api = {
+ post: vi.fn().mockResolvedValue({ data: { status: "success" } }),
+ };
+
+ const first = AboutPage.methods.restoreFromSnapshot.call(ctx, "/storage/snapshots/a.zip");
+ expect(ctx.restoreInProgress).toBe(true);
+ const second = AboutPage.methods.restoreFromSnapshot.call(ctx, "/storage/snapshots/b.zip");
+ await Promise.all([first, second]);
+
+ expect(window.api.post).toHaveBeenCalledTimes(1);
+ expect(DialogUtils.confirm).toHaveBeenCalledTimes(1);
+ expect(ToastUtils.success).toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(reloadSpy).toHaveBeenCalled();
+ expect(ElectronUtils.relaunch).not.toHaveBeenCalled();
+ expect(ctx.restoreInProgress).toBe(false);
+
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: originalLocation,
+ });
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────